以下是本文的思维导图:
环境: webpack v4.46.0,Nodejs v16.6.2
安装
新建项目文件夹并安装 webpack 和 webpack-cli:
mkdir project
cd project
npm init -y
npm install webpack@4.46.0 webpack-cli@3.3.2 --save-dev
在项目根目录下新建 webpack.config.js
,作为 webpack 的默认配置文件。
核心概念
module、chunk 和 bundle
用一张图来方便理解:
简单地说,module 是任何通过 import 或者 require 导入的代码,包括 js、css、图片资源等;多个 module 可以组成一个 chunk,每个 chunk 经过处理后会输出一个 bundle 文件
entry 和 output
1)单入口单出口
以 src 目录下的 index.js
作为入口文件,打包输出文件 bundle.js
到 dist 目录下
const path = require('path')
module.exports = {
entry: './src/index.js',
output: {
path:path.resolve(__dirname,'dist'),
filename: 'bundle.js'
}
}
这种配置一般用于单页应用,最终只会产生一个 chunk。
2)多入口单出口
对应的 entry
改用数组:
const path = require('path')
module.exports = {
entry: ['./src/index1.js','./src/index2.js'],
output: {
path:path.resolve(__dirname,'dist'),
filename: 'bundle.js'
}
}
这种配置一般用于多页应用,但最终也只会产生一个 chunk。
3)多入口多出口
对应的 enrty
改用对象,key 表示打包后输出文件的名字,output.filename
中用占位符表示这个名字:
const path = require('path')
module.exports = {
entry: {
bundle1: './src/index1.js',
bundle2: './src/index2.js'
},
output: {
path:path.resolve(__dirname,'dist'),
filename: '[name].js'
}
}
这种配置一般用于多页应用,且最终会产生多个 chunk。
mode
指定 webpack 进行打包构建的环境是开发环境还是生产环境 —— 根据环境的不同,webpack 会默认开启不同的优化选项。
loader
loader 相当于是一个转换器。webpack 默认只能解析 js / json 文件,对于其他类型的文件需要借助 loader 进行转换,之后才能解析。
plugin
webpack 执行打包构建的生命周期中会触发很多事件,plugin 监听某些事件并执行那些 loader 做不了的特定任务。
易混淆的配置项
filename 和 chunkFilename
filename 很好理解,就是 entry 入口文件对应输出文件的名字,而 chunkFilename 指的是没有被列入 entry 中,但在某些情况下又不得不单独打包输出的文件的名字。
典型的例子就是动态加载模块,比如说入口文件 index.js 如下:
// 注意这里使用了魔法注释指定了 chunk 名
const res = await import('/* webpackChunkName: "test"*/ ./test.js')
webpack 的配置如下:
module.exports = {
entry: {
index: path.resolve(__dirname,'./src/index.js')
},
output: {
path: path.resolve(__dirname,'./dist'),
filename: '[name].js',
chunkFilename: '[name].chunk.js'
}
}
打包后,entry 入口文件会对应产生一个叫做 index 的 chunk,同时输出一个叫做 index.js 的 bundle 文件。而 test.js 是动态加载的,它会被单独打包到另一个叫做 test (通过魔法注释指定)的 chunk 中,同时输出一个叫做 test.chunk.js 的文件。
path 和 publicPath
path 很好理解,就是 entry 入口文件对应输出文件的路径,而 publicPath 指的是引用静态资源时的固定前缀。项目上线后通常可以配置 publicPath 为一个 cdn 地址,这样就可以引用部署到 cdn 上的静态资源了。
hash、contenthash 和 chunkhash
配置 bundle 文件名的时候,通常可以使用 xxx.[hash].js
这样的命名形式,这里的占位符可以使用 hash、chunkhash 以及 contenthash。
这三个 hash 通常和 CDN 缓存有关,代码改变触发文件 hash 改变,hash 改变导致资源引用的 URL 改变,从而触发 CDN 回源从服务器重新拉取最新的资源。
以多页面应用为例,假设有 A、B 两个页面。
- hash:是项目编译层面的 hash,全局一致,任何文件的修改都会导致它发生改变。这意味着 A 页面文件的改变会导致整体 hash 改变,从而影响采用了 hash 命名的 B 页面文件,这样是无法实现静态资源缓存的
- chunkhash:是 chunk 层面的 hash,每个入口页面对应一个 chunk,其产生的相关 bundle 共用同一个 chunkhash。修改 A 页面文件只会影响 chunk A,不会影响 chunk B
- contenthash:是单文件层面的 hash,粒度要更精细。假设 A 页面中的 js 引用了 css,那么 js 文件改变所导致的 chunkhash 改变也会作用到 css 文件上,因此这时候的做法是利用 plugin 抽离出 css,并采用 contenthash 命名,标志每一个单独的文件
PS:另外需要注意的是,chunkhash/contenthash 和 HMR 热更新不能一起使用。因为热更新针对的是开发环境,chunkhash 以及 contenthash 针对的是生产环境(涉及到 CDN 缓存)。
资源解析
解析 ES6 语法
安装 babel 相关依赖(preset-env
对应的是 ES6 的 preset):
cd project
npm install babel-loader @babel/core @babel/preset-env --save-dev
项目根目录下增加 .babelrc
文件:
{
"presets":[
"@babel/preset-env"
]
}
增加 module.rules
项,配置 loader:
module.exports = {
module:{
rules:[
{test: '/\.js$/', use: 'babel-loader'}
]
}
}
解析 CSS 和 LESS/SASS/Stylus
以加载和解析 less 文件为例。less-loader 将 less 文件解析为 css 文件,css-loader 解析 css 文件,style-loader 将 css 文件中的样式注入到 style 标签中。
安装:
// 注意 less 属于 less-loader 的 peerDependencies,npm 会自动安装
npm i less-loader css-loader style-loader -D
配置:
module.exports = {
module: {
rules:[
{
test: /\.less$/ ,
// 注意要按照从右到左依赖的顺序编写
use: ['style-loader','css-loader','less-loader']
}
]
}
}
解析图片和字体
解析图片或者字体需要用到 file-loader:
module.exports = {
module:{
rules:[
{
test: /\.(png|jpg|gif|svg)$/,
use: 'file-loader'
}
]
}
}
url-loader 是对 file-loader 的封装,可以提供 limit
参数:当图片体积小于 limit 参数的时候,使用 url-loader 进行处理,会用 base64 对图片进行编码,通过 dataUrl 引用图片;否则使用 file-loader 进行处理。注意这里一定要设置 esModule: false
,否则图片和字体默认会被视为 ES 模块,无法在页面中正常引用。
module.exports = {
module:{
rules:[
{
test: /\.(png|svg|jpg)$/,
use: [{
loader: 'url-loader',
options: {
limit: 4000,
esModule: false
}
}]
}
]
}
}
资源内联
资源内联可以提高项目文件的可维护程度,并减少请求数量。以多页面应用为例,假如每个页面都有公用的 meta 信息,不可能每个 .html
文件都去写一遍,这时候就可以把 meta 信息集中到一个 meta.html
中,在需要用到的页面内联进去即可。
源文件内联:HTML 和 JS
内联 HTML 和 JS 可以使用 raw-loader@0.5.1。
inline.html
文件:
<span>我是内联 HTML 文件</span>
inline.js
文件:
const message = '我是内联 JS 文件'
模板 HTML 文件:
<!-- html-webpack-plugin 支持解析 ejs 语法 -->
<div>我是模板 HTML 文件</div>
<%= require('raw-loader!./inline.html')%>
<script>
<%= require('raw-loader!babel-loader!./inline.js')%>
</script>
构建后生成的 index.html
文件:
<div>我是模板 HTML 文件</div>
<span>我是内联 HTML 文件</span>
<script>
const message = '我是内联 JS 文件'
</script>
PS:这里之所以要使用 0.5.1 版本的 raw-loader,是因为这个版本的 raw-loader 默认采用 CJS 的模块导出方案,所以可以使用 require 直接导入;之后的版本则默认采用 ES Module 导出方案,如果想使用高版本,有两种方法:
- 通过
<%= require('...').default %>
require 一个 ES6 模块 - 修改 raw-loader 源代码,默认导出方式改为采用 CJS
源文件内联:CSS
两种方案:
- 方案一:直接使用上面提到的 style-loader,通过 JS 将样式动态注入到
style
中,这种方式下构建产物中不会直接出现样式代码; - 方案二:先使用 mini-css-extract-plugin 抽离出 css 文件到构建产物中,并且在 html 文件中通过 link 引用该 css,再使用 html-inline-css-webpack-plugin 将对于 css 文件的 link 引用转化为内联形式。下面介绍第二种方案。
安装:
npm i mini-css-extract-plugin html-inline-css-webpack-plugin -D
配置:
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const HTMLInlineCSSWebpackPlugin = require('html-inline-css-webpack-plugin').default
module.exports = {
plugins: [
// 先导出成 css 文件,通过 link 引用
new MiniCssExtractPlugin()
// 再将 link 引用转化为内联形式
new HTMLInlineCSSWebpackPlugin()
]
module:{
rules: [
{
test: /\.css$/,
loader: [
// 这里必须开启 plugin 的 loader
MiniCssExtractPlugin.loader,
'css-loader'
]
}
]
}
}
注意:
- 需要同时配置 loader 和 plugin。另外,
MiniCssExtractPlugin.loader
和style-loader
功能上是冲突的,不能一起使用 - require 的时候需要使用
require(..).default
,因为 html-inline-css-webpack-plugin 导出方式是采用 ES Module。 - 默认情况下,使用了 html-inline-css-webpack-plugin 之后,不会保留由 mini-css-extract-plugin 导出的 css 文件
构建产物内联:CSS 和 JS
前面讲的内联,都是内联 src 下的文件到 html 中,那么有没有办法可以将 bundle 中的 css 和 js 文件内联到 html 中呢?这就要使用 html-webpack-inline-source-plugin 了。
它是 html-webpack-plugin 的一个插件,所以两者都要安装。之后进行配置:
module.exports = {
plugins: [
new HtmlWebpackPlugin({
// 正则匹配需要内联的文件
inlineSource: /\.(js|css)$/
}),
// 注意这里必须传参
new HtmlWebpackInlineSourcePlugin(HtmlWebpackPlugin)
]
}
PS:注意必须指定安装 @1.0.0-beta.2 版本的 plugin,npm 默认安装的是旧版本,有 bug。
图片和字体内联
图片和字体的内联,其实就是使用前面提到过的 url-loader,注意需要设置 esModule: false
。
开发和构建体验
文件监听
文件监听也就是 watch mode。开启文件监听后,每次源代码发生更改,都会自动重新进行构建。
module.exports = {
watch: true,
watchOptions: {
ignored: /node_modules/,
aggregateTimeout: 2000,
poll: 1000
}
}
文件监听的原理是轮询文件的“最后一次编辑时间”是否改变。ignored
指定忽略监听的文件或者文件夹;poll
表示每秒轮询多少次;此外,并不是文件一更改就马上重新构建,必须是在 aggregateTimeout
指定的时间内没有再次更改之后,才会重新构建,有点类似于做了一层防抖处理,避免频繁构建。
热重载
热重载也就是 live reload,可以在每次源代码发生更改时自动重新进行构建 + 自动刷新浏览器。这里需要使用 webpack-dev-server 实现热重载。
安装:
npm i webpack-dev-server -D
在 package.json 中配置指令:
{
"scripts": {
"dev": "webpack-dev-server" // npm run dev 运行开启本地服务器
}
}
配置 webpack-dev-server:
module.exports = {
devServer: {
open: true, // 构建完成后自动打开浏览器
port: 8888, // 监听端口
contentBase: './dist' // 服务器根路径
}
}
热更新
热重载也有缺点,就是每次都会全局刷新浏览器,所有的状态都会重置。所以在热重载的基础上引入了热更新 —— 也就是 HMR(模块热替换),它既可以实现局部视图刷新,也可以保存数据状态。这里需要使用 webpack 内置的 HotModuleReplacementPlugin 实现热更新。
const webpack = require('webpack')
module.exports = {
devServer: {
hot: true // 开启热更新
},
plugins:[
new webpack.HotModuleReplacementPlugin()
]
}
开启 sourcemap
在生产环境下(mode: production
),打包后的文件都是经过压缩的,代码出错后不容易调试。而开启 sourcemap 之后,可以直接定位到出错的源代码,调试就很方便了。
module.exports = {
mode: 'none',
devtool: 'sourcemap'
}
代理跨域请求
本地开发的时候我们会起一个 http://localhost:8080 的服务器,这时候请求后端接口 http://mysite/api/getData会报跨域错误。此时可以通过 webpack-dev-server 配置一层与本地服务器同源的代理服务器,它会接受请求,再将请求转发给真正的后端服务器(同源仅作用于浏览器和服务器之间,所以这个转发是没问题的)。
举个例子,这是我们发起的请求:
axios.get('/api/getData/')
.then(res => console.log(res))
这是 webpack-dev-server 的跨域配置:
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://mysite'
}
}
}
}
我们发起请求的 url /api/getData/
会自动加上同源前缀,变成 http://localhost/api/getData,相当于现在是向同源的代理服务器发起请求;又由于 url 可以匹配 /api
,所以这个请求会被进一步转发给真正的后端服务器,相当于发起 http://mysite/api/getData 这个请求。
自动引用构建产物
默认情况下,我们可能需要在 dist 目录下手动创建一个 html 文件去引用构建产物,这是比较麻烦的。通过 html-webpack-plugin,可以在 dist 目录下自动生成一个引用构建产物(资源)的 html 文件。
安装:
npm i html-webpack-plugin -D
配置:
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
plugins: [
new HtmlWebpackPlugin({
// 提供一个模板 html 文件作为基础
tamplate: path.resolve(__dirname,'./src/index.html')
})
]
}
优化构建日志的显示
构建日志中可能包含很多我们并不关心的信息,可以借助 plugin 优化一下。
npm i friendly-errors-webpack-plugin -D
配置:
const friendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin')
module.exports = {
stats: 'errors-only'
devServer: {
stats: 'errors-only'
}
plugins: [
new friendlyErrorsWebpackPlugin()
]
}
捕获构建错误
每次构建结束后会触发 compiler 对象的 done 钩子函数,可以在这个 hook 中捕获构建错误并进行相关处理:
module.exports = {
plugins: [
function() {
this.hooks.done.tap('done', (stats) => {
if (stats.compilation.errors &&
stats.compilation.errors.length &&
process.argv.indexOf('--watch') == -1)
{
// 进行相关处理
process.exit(1);
}
})
}
]
}
分离配置文件
开发应用的时候一般有两种环境,一个是方便开发调试的开发环境,一个是上线后给用户使用的生产环境。不同的环境,webpack 的配置也不同,比如生产环境需要配置代码压缩,开发环境需要配置热更新等。我们现在是共用一个 webpack.config.js 文件,所以需要解决的问题是:如何根据不同环境使用不同的 webpack 配置文件?
方案一: cross-env + NODE_ENV
我们的基本策略是分离三个配置文件:
- webpack.base.js:开发环境和生产环境共用的配置放在这里
- webpack.dev.js:开发环境专用的配置放在这里
- webpack.prod.js:生产环境专用的配置放在这里
node 有一个 process 对象,我们在 process.env
上挂载一个 NODE_ENV
环境变量,用来标记当前是什么环境。因为不同操作系统设置环境变量的方式不同,为了方便统一设置,这里使用 cross-env 这个库。接着,我们在所有文件中都可以通过 node.env.NODE_ENV
获取当前环境类型。
package.json 文件:
"scripts": {
// 运行 build 肯定是构建打包,所以设置为生产环境,并使用指定文件作为配置文件
"build": cross-env NODE_ENV = 'production' webpack --config webpack.prod.js
// 运行 dev 肯定是构建调试,所以设置为开发环境,并使用指定文件作为配置文件
"dev": cross-env NODE_ENV = 'development' webpack-dev-server webpack.dev.js
}
webpack.base.js 文件:
module.exports = {
// entry、output 等共用配置,以及一些共用的 loader 和 plugin
// 有的配置虽然是共用,但因为环境不同,需要使用不同值,比如 sourcemap 是否开启:
devtool: process.env.NODE_ENV === 'production' ? 'none' : 'sourcemap'
}
webpack.prod.js 文件:
// merge 可以快速合并两个 webpack 配置
const merge = require('webpack-merge')
const base = require('./webpack.base.js')
module.exports = merge(base,{
mode: 'production'
// 生产环境专用的配置
})
webpack.dev.js 文件:
const merge = require('webpack-merge')
const base = require('./webpack.base.js')
module.exports = merge(base,{
mode: 'development'
// 开发环境专用的配置
})
方案二:配置文件导出函数
基本策略是仍然使用单个配置文件,但是导出的不再是对象,而是函数。运行构建命令的时候传入一个 mode 参数,这个参数被函数接受,从而判断当前环境。
"scripts": {
// 这里配置的 mode 参数会传给配置文件中导出的函数
"build": webpack --config webpack.config.js --mode production
"dev": webpack-dev-server webpack.config.js --mode development
}
webpack.config.js 文件:
module.exports = (mode) => {
// 公共配置
if(mode === 'production'){
// 生产环境配置
} else {
// 开发环境配置
}
}
项目开发
处理 CSS
postcss 本身提供了一个强大的插件系统,可以对 css 进行后处理。在 webpack 中,需要通过 postcss-loader 去使用 postcss。
1)自动补齐前缀
为了向下兼容旧浏览器,某些比较新的 css 属性必须加上浏览器厂商的前缀,可以通过 postcss-loader 和 autoprefixer 实现前缀的自动补齐。
安装:
npm i postcss-loader autoprefixer -D
配置:
module.exports = {
module: {
rules:[
{
test: /\.css$/,
loader:[
'style-loader',
'css-loader',
{
loader: 'postcss-loader',
options: {
// 使用 postcss 的插件
plugins: [
require('autoprefixer')({
browsers:['last 2 version','>1%','iOS 7']
})
]
}
}
]
}
]
}
}
2)单位转换
移动端的适配分为两步:
- 根据屏幕分辨率动态设置根元素的字体大小。这里可以使用手淘的 lib-flexible 或者 viewport 单位来实现,这样,1rem 的大小就是动态的了
- 根据设计稿的 px 进行开发,最后通过插件将 px 统一转化为当前开发使用的分辨率下对应的 rem。
这里进行单位转化的插件,就可以使用 px2rem-laoder、postcss-plugin-rem 等来完成。
代码规范
集成 eslint
1)单独使用
单独使用 eslint,首先需要安装 eslint 本体:
npm i eslint -D
生成 eslint 配置文件 .eslintrc.json:
npx eslint --init
根据我们回答的问题,会预先配置好文件中的一些选项。如果想要使用其它公司团队的 eslint 规范,需要单独安装:
npm i eslint-config-airbnb -D
并修改配置文件中的 extends
选项:
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
// 这里修改
"airbnb"
],
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"rules": {
// 自定义 lint 规则
}
}
运行下面命令对指定文件进行 lint 检测:
npx eslint ./src/index.js
2)集成到 webpack 中使用
在 webpack 中集成 eslint 有两种方式,一种是 eslint-loader,但它存在一些问题,不久将被弃用;webpack 5 开始更推崇使用 eslint-webpack-plugin。这里以后者为例。
首先安装:
npm i eslint-webpack-plugin -D
接着到项目根目录下新建 .eslintrc.json 文件,内容和上面差不多。如果想要使用某个公司团队的 eslint 规范,同样需要单独安装 npm 包。
然后配置 webpack.config.js:
const ESLintPlugin = require('eslint-webpack-plugin')
module.exports = {
plugins: [
new ESLintPlugin()
]
}
基本使用就是这样了,每次运行构建 eslint 都会检测代码。默认情况下 eslint 的报错信息采用的是 stylish 的展示风格,可能不太直观,可以使用特定的插件修改报错信息的展示风格。安装:
npm i eslint-formatter-friendly -D
配置:
const ESLintPlugin = require('eslint-webpack-plugin')
module.exports = {
plugins: [
new ESLintPlugin({
formatter: require('eslint-formatter-friendly')
})
]
}
集成 stylelint
在 webpack 中集成 stylelint 有三种方式:
- 使用 stylint-loader(官方已弃用,不推荐)
- 使用 postcss-loader + stylelint
- 使用 stylelint-webpack-plugin
这里以第三种方式为例。首先安装:
npm i stylelint-webpack-plugin -D
在项目根目录下新建 .stylelintrc.json 文件:
{
"rules": {
"unit-no-unknown": true
}
}
配置:
const StylelintPlugin = require('stylelint-webpack-plugin')
module.exports = {
plugins: [
new StylelintPlugin()
]
}
每次运行构建 stylelint 都会检测代码。
PS:以上两种 lintPlugin 的安装都不需要额外安装 eslint 或者 stylelint,因为 npm 从 v7 开始会自动安装 peerDependencies。
搭建 Vue 开发环境
vue-cli 集成了 vue 和 webpack,但还是有必要掌握如何用 webpack 搭建一个基础的 Vue 开发环境。
创建目录并安装相关依赖:
mkdir vue-webpack && cd $_
npm init -y
// 安装 webpack
npm i webpack webpack-cli -D
// 安装解析 html 的插件
npm html-webpack-plugin -D
// 安装 vue(默认安装 vue2,vue@next 可以安装 vue3)
npm i vue --save
// 安装 vue-loader 和 vue-temlplate-compiler
npm i vue-loader vue-temlplate-compiler -D
新建 src 文件夹,src/App.vue
是 SFC 单文件组件:
<template>
<div id="app">
Hello Vue
</div>
</template>
<script>
export default {}
</script>
<style scoped>
</style>
src/index.js 作为打包入口:
import Vue from 'vue'
import app from './App.vue'
new Vue({
el: '#project',
render: h=>h(app)
})
src/index.html 作为构建产物使用的模板 html:
// vue 挂载在这个 dom 上
<div id="project"></div>
配置 webpack:
const VueLoaderPlugin = require('vue-laoder/lib/plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: path.resolve(__dirname,'./src/index.js')
output: {
path: path.resolve(__dirname,'./dist')
filename: '[name].js'
}
module: {
rules: [
{
test: /\.vue$/,
use: 'vue-loader'
}
]
}
plugins: [
new VueLoaderPlugin()
new HtmlWebpackPlugin({
template: './src/index.html'
})
]
}
注意关于 vue 的几个依赖的作用:
- vue-loader:用于提取
.vue
中的各个语言块 - VueLoaderPlugin:将 webpack 声明的规则应用于对应的语言块,比如 css-loader、style-loader 会同时作用在
.vue
文件中的<style></style>
语言块 - vue-template-compiler:将 template 预编译成函数,避免运行时进行模板编译,从而加快应用运行速度
项目打包
多页面应用打包
对于多页面应用来说,每个页面都对应“一个 entry + 一个 HtmlWebpackPlugin 实例”。如果每次添加或者删除页面都需要重新配置那就太麻烦了,因此理想的方案是根据页面情况实现自动配置。
假设项目结构如下:
+-- src
| +-- page1
| +-- index.js
| +-- index.css
| +-- index.html
| +-- page2
| +-- index.js
| +-- index.css
| +-- index.html
page1 和 page2 目录下都有一个 index.js 表示页面入口,index.html 表示页面的模板 html。
首先安装 glob 方便读取文件路径:
npm i glob -D
通过一个 setMPA 函数处理多页面应用配置:
const glob = require('glob')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const setMPA = () => {
const entry = {}
const HtmlWebpackPlugins = []
// 返回所有匹配的路径
const entryPaths = glob.sync(path.join(__dirname,'./src/*/index.js'))
entryPaths.forEach(path => {
// 从路径中提取出页面名
const entryName = path.match(/\/src\/(.*?)\/index\.js/)
entry[entryName] = path
HtmlWebpackPlugins.push(new HtmlWebpackPlugin({
// 构建的 html 采用的模板
template: `./src/${entryName}/index.html`,
// 构建的 html 的名字
filename: `${entryName}.html`,
// 为构建的 html 注入哪些 chunk(MPA 一定要设置这个,否则会注入所有 chunk)
chunks: [entryName]
}))
})
}
调用 setMPA 生成配置对象,注入到 webpack 的配置中:
const {entry,HtmlWebpackPlugins} = setMPA()
module.exports = {
entry,
plugins:[
...HtmlWebpackPlugins
]
}
基础组件库打包
以打包 + 发布一个 promise 为例。
mkdir chor-promise
cd chor-promise
npm init -y
npm i webpack webpack-cli -D
新建 ./src/index.js ,编写核心代码。接着新建 webpack.config.js 进行打包配置:
const path = require('path')
module.exports = {
entry: {
// 打包两份文件,分别是压缩版和未压缩版
'chor-promise.min': path.resolve(__dirname,'./src/index.js'),
'chor-promise': path.resolve(__dirname,'./src/index.js')
},
output: {
path: path.resolve(__dirname,'./dist'),
filename: '[name].js',
// 使用该库时 import 的名字
library: 'myPromise',
// 不设置的话需要通过 xxx.default 才能使用该库
librarayExport: 'default',
// 可以以 AMD、CJS 或者 ESM 的方式使用该库
librarayTarget: 'umd',
// 定义全局对象,必须设置,否则会报 self 或者 window 未定义的错误
globalObject: 'this'
}
}
新建 ./index.js 作为该库的入口文件:
// 根据用户使用该库的时候是开发环境还是生产环境,决定导出压缩版还是未压缩版
if(process.env.NODE_ENV === 'production'){
module.exports = require('./dist/chor-promise.min')
} else {
module.exports = require('./dist/chor-promise')
}
打包:
npm run build
发布:
// 登录 npm 账号
npm login
// 将该库作为 npm 包发布
npm publish
使用:
首先正常安装
npm i chor-promise -D
在需要的文件中导入使用:
import myPromise from 'chor-promise'
myPromsie.resolve(123).then(r => console.log(r))
webpack 性能优化
如何进行性能分析
webpack 本身可以配置 stats 展示打包构建的信息,但这些信息颗粒度比较大,不利于进行性能分析。所以这里还是要借助插件来分析。
分析构建速度
性能分析其一,用 speed-measure-webpack-plugin 分析构建速度,它可以分析每个 loader 和 plugin 的耗时。安装后配置如下:
const speedMeasureWebpackPlugin = require('speed-measure-webpack-plugin')
const smp = new speedMeasureWebpackPlugin()
// 用 smp.wrap 去包裹 webpack 的配置对象
module.exports = smp.wrap({
//.....
})
耗时长的 loader 或者 plugin 会显示为红色,这也是我们需要关注并优化的重心。
分析打包体积
性能分析其二,用 webpack-bundle-analyzer 分析打包体积,在浏览器的 8888 端口下可以看到每个文件的体积信息以及各个 chunk 的包含关系,方便我们进行分析。
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
module.exports = {
plugins: [
new BundleAnalyzerPlugin()
]
}
PS:安装这个 npm 包可能会报很恶心的 node gyp 错误,可以尝试切换回 npm 原来的镜像源(不要使用淘宝镜像源)
文件结构优化
文件结构优化指的是要合理地拆分代码文件。为什么要拆分代码文件呢?一般是从两个角度考量:
- 更好地利用缓存:假如 css 没有从 js 文件中分离出来,那么每次 js 或者 css 改变,用户都得重新下载整个文件;而分离之后,两者独立,一方改变后,另一方的缓存仍可利用,无需重新下载
- 更好地复用代码:如果开发的是多页面应用,可以把公共样式单独提取成一个文件,这样公共样式文件只需要下载一次,而不是每进入一个页面就要重复下载
合理使用动态加载
通过 import()
或者 require.ensure()
对某些体积较大的模块实现按需加载、动态加载的时候,这些模块会打包到单独的文件中。如果用户用不到这个模块,那么他们就无需加载它,不再像之前那样一股脑地加载整个代码文件。
多页面应用使用动态路由
对于多页面应用,采用之前提到的多页面应用打包方案,使每个页面都有自己对应的文件,这样用户在进入某个页面的时候,只需要加载和这个页面相关的资源,而不是全部一次性加载。
splitChunks 代码分割
形成 chunk 的方法有三种:
- 设置多个 entry 入口点,每个 entry 会被打包到一个 chunk 中
- 动态导入某些代码,这些代码会被打包到一个 chunk 中
- 通过 splitChunks 分割代码,分割的代码会被打包到一个 chunk 中
通过配置 optimization.splitChunks.cacheGroups
,可以将公用的第三方库代码抽离成一个单独的 chunk,下面通过一个例子来理解这个过程。
假设我们的应用有两个页面,对应两个 entry 入口文件:
// page1.js
import $ from 'jquery'
import React from 'react'
import(/* webpackChunkName: "page1-lodash"*/ 'lodash')
// page2.js
import $ from 'jquery'
import(/* webpackChunkName: "page2-react" */ 'react')
import(/* webpackChunkName: "page2-lodash"*/ 'lodash')
webpack 配置如下:
module.exports = {
optimization: {
splitChunks: {
// 这里的默认配置项省略,它们最终都会作用到 cacheGroups 上
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
// 这里的 chunks 字段指的是分离 chunk 的标准
chunks: 'async'
}
}
}
}
}
chunks: “async”
chunks 的默认值就是 async,表示会将异步导入(动态导入)的模块抽离成单独的 chunk。
**对于 page1.js:**本身 entry 文件就会对应一个 chunk,而 jq 和 react 都是同步导入的,因此不会从这个 chunk 中分离,它们三个最终会打包到一起,并输出到 page1.bundle.js 文件。而 lodash 是动态导入的,会分离到一个单独的 chunk 中,并输出到 vendors~page1-lodash.js 文件
**对于 page2.js:**本身 entry 文件就会对应一个 chunk,而 jq 是同步导入的,因此不会从这个 chunk 中分离,它们两个最终会打包到一起,并输出到 page2.bundle.js 文件。而 lodash 是动态导入的,它会和 page1.js 中同样动态导入的 lodash 一起打包到同一个 chunk 中,最终输出到 vendors~page1-lodash.js 文件。react 也是动态导入的,它也会打包到一个单独的 chunk 中,最终输出到 vendors~page2-react.js 文件
综上,最终会有 4 个 chunk,输出到 4 个 bundle 文件中。从控制台打印信息可以看出确实是这样的:
借助 BundleAnalyzePlugun 可以更加直观地分析 bundle 的成分,如下图:
chunks: “initial”
initial 表示,不管模块是同步导入还是异步导入,都会被抽离成单独的 chunk。如果不同的 chunk 都通过同步导入的方式共用了同一个模块,则这两个模块可以被抽离到同一个 chunk 中。
- 首先还是同样的,page1.js 和 page2.js 本身的 entry 文件会分别对应两个 chunk,并输出到 page1.bundle.js 和 page2.bundle.js 中。
- 由于两个 chunk 都同步导入了 jq,因此 jq 最终被抽离到一个 chunk 中,并输出到 vendors~page1-page2.js 文件。
- 对于都异步导入的 lodash 也是一样,会输出到 page1-lodash.js 文件。
- 而对于 react 的处理就不同了,虽然两个文件都导入了 react,但一个是同步导入,一个是异步导入,这种情况下,react 会被分别抽离到两个 chunk 中,同步导入的 react 输出到 vendors~page1.js,异步导入的 react 输出到 page2-react.js。
综上,最终会有 6 个 chunk,输出到 6 个 bundle 文件中。
控制台打印信息如下:
BundleAnalyzePlugun 的分析情况如下:
PS:使用 chunks:"initial"
的时候需要注意,会有一个 minSize 字段表示被抽离成单独 chunk 的模块至少需要多大,如果模块体积本身小于这个值,则它也不会被单独抽离成 chunk,而是和 entry 对应的 chunk 打包在一起。
chunks: “all”
all 的特点在于,只要两个 chunk 共用了同一个模块,则不管模块在各自的 chunk 中是同步导入还是异步导入,最终都可以被抽离到同一个单独的 chunk 中。
- page1.js 和 page2.js 本身的 entry 文件会分别对应两个 chunk。
- 由于这两个 chunk 共用了 jq,所以 jq 被抽离到一个单独的 chunk 中,最终输出到 vendors~page1-page2.js
- 由于这两个 chunk 共用了 lodash,所以一样的,被抽离到一个 chunk 中,最终输出到 vendors~page1-lodash.js
- 对于 react,虽然在各自 chunk 中导入方式不同,但确实是属于共用的模块,所以也会被抽离到一个 chunk 中,最终输出到 vendors~page1-page2-react.js
控制台打印信息如下:
BundleAnalyzePlugun 的分析情况如下:
从这三种设置的结果可以看出,chunks:"all"
是可以最大程度复用代码的,因为在它的规则下,只要是模块被共用了,就可以被抽离到同一个 chunk 中。
构建速度优化
减小文件搜索范围
webpack 打包构建的过程中会进行很多搜索过程,如果可以通过修改配置减小文件搜索的范围,那么就可以提高构建速度。
从配置 noParse 的角度来说:
默认情况下,我们导入 jq 或者 lodash 这样的库时,webpack 会去递归地解析这些库是否有其他第三方依赖。这个过程其实是不必要的,所以可以通过 noParse 配置不需要递归解析的模块:
module.exports = {
noParse: '/lodash|jquery/'
}
从配置 resolve 的角度来说:
resolve.alias
可以配置路径的别名,减少类似 import xxx from ‘…/…/a/b’ 这样繁琐的导入语句。不仅开发上更加方便,而且 webpack 解析到别名的时候,可以直接去对应的目录找到模块。
module.exports = {
resolve: {
alias: {
// 用 @ 代替路径 '/project/src'
'@': '/project/src'
}
}
}
使用:
// 模块导入
import xxx from '@/assets/test.png'
// HTML 中使用
<img src="~@/assets/test.png">
// CSS 中使用
.box {
background: url('~@/assets/test.png')
}
注意,在 HTML 或者 CSS 中使用别名路径的时候,必须加 ~
前缀。另外,必须安装 html-loader 和 css-loader,webpack 才能正确解析别名路径对于资源的引用。
resolve.extensions
提供一个后缀名数组,如果像 import
这样的导入语句省略了文件后缀名,则会为文件依次加上数组中的后缀名,看文件是否存在。这意味着我们经过配置后,在导入语句中可以省略文件的后缀名。
module.exports = {
resolve: {
// 默认可以省略 .js 和 .json 后缀名
extensions: ['.js','.json']
// 在默认省略的后缀名基础上自定义
extensions: ['vue','...']
}
}
一般来说,应该将出现频率较高的后缀名写在前面,加快 webpack 解析时的匹配速度。另外不应该配置并省略过多的后缀名,否则会增加 webpack 解析时的查找时间。
resolve.modules
指定 webpack 去哪些路径下查找模块,默认会从项目根目录开始找,找不到就往外层找。一般我们自己写的模块或者第三方模块都在项目根目录下了,所以可以指定一下目录,减少不必要的向外查找。
module.exports = {
modules: [
path.resolve(__dirname,'./node_modules'),
path.resolve(__dirname,'./src')
]
}
resolve.mainFields
指定的是去查找第三方模块的 package.json
文件的哪些字段,从而找到模块的入口文件。一般来说入口文件都配置在 main 字段中,所以可以直接将其配置为 ['main']
。
从配置 loader 的角度来说
可以排除掉一些不需要解析的文件,或者精准指定需要解析的文件,从而减小解析时间,加快构建速度。
以 babel-loader 为例,默认情况下它会解析根目录中的所有 js 文件,但实际上,node_modules 中的很多第三方包本身就已经经过处理了,无需再进行解析,那么这部分就可以排除掉;同时,我们需要解析的通常是自己编写的代码,所以可以明确指定解析 src 目录下的文件:
module.exports = {
module: {
rules:[
{
test: /\.js$/,
use: 'babel-loader',
include: path.resolve(__dirname,'./src'),
exclude: path.resolve(__dirname,'./node_modules')
}
]
}
}
多进程并行构建
webpack@4 之前使用 HappyPack,webpack@4 之后使用 thread-loader。安装后配置:
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use:[
{
loader: 'thread-loader',
options: {
workers: 3
}
},
'babel-loader'
]
}
]
}
}
排在 thread-loader 之后的那些 loader 会被放在一个进程池中单独运行。这里需要注意,进程池中的 loader 不能产生新文件,因此类似 MiniCssExtractPlugin.loader 这样产生 css 文件的 loader 是不能使用 thread-loader 的。
PS:不管是 HappyPack 还是 thread-loader 都只适用于大型项目,小型项目中的优化很不明显,甚至可能反而降低打包构建的速度。
多进程并行压缩
CSS 和 JS 的压缩可以开启多进程并行压缩(默认开启):
module.exports = {
optimization: {
minimizer: [
// JS 并行压缩
new TerserPlugin({
parallel: 4
})
// CSS 并行压缩
new CssMinimizerPlugin({
parallel: 4
})
]
}
}
利用缓存提升二次构建速度
前面讲到的都是提升首次构建速度,我们可以将首次构建的结果缓存下来,然后利用缓存提升二次构建的速度。
开启 babel-loader 的缓存功能:
module.exports = {
module: {
rules: [
{
test: /\.js$/.
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: true
}
}
]
}
]
}
}
开启 terser-webpack-plugin 的缓存功能:
module.exports = {
plugins: [
new TerserPlugin({
cache: true
})
]
}
或者使用 cache-loader 缓存构建结果:
module.exports = {
module: {
rules: [
{
test: /\.js$/.
use: ['cache-loader','babel-loader']
}
]
}
}
或者使用 hard-source-webpack-plugin 缓存构建结果:
module.exports = {
plugins: [
new HardSourceWebpackPlugin(),
// 和 MiniCssExtractPlugin 的 loader 冲突,必须对其进行排除
new HardSourceWebpackPlugin.ExcludeModulePlugin([
{
test: /mini-css-extract-plugin[\\/]dist[\\/]loader/,
}
])
]
}
打包体积优化
优化打包体积即减小打包体积,基本策略是对文件体积进行压缩,或者设法减少文件的数量。
webpack 自带:Tree-Shaking
tree-shaking 就是所谓的摇树优化,可以实现 DCE,即去除没有用到的代码,包括:
- 永远不会执行的、不可达的代码
- 执行结果没有被用到的代码
- 只会影响死变量(指变量赋值了,但之后再也没有读取)的代码
在讲 tree-shaking 之前,首先要理解静态分析的概念。静态分析指的是不需要实际执行代码,仅从字面量就可以对代码进行分析,诸如 import()
和 CommonJS 的 require()
都是动态的,而 ESModule 则不一样,它支持静态分析 —— 在使用 ESModule 的时候,模块是否导入、是否导出、模块之间的依赖关系,这些都是可以提前确定好的,而这种静态分析的特性正是实现 tree-shaking 的关键。
tree-shaking 如何发挥作用呢?以下面的代码为例:
// export.js
export function a(){}
export function b(){}
// index.js
import {a} from ''./export.js'
a()
虽然只是导入并使用了 a,但实际上最终 a 和 b 都会被打包到 bundle 中,这会无形增加代码体积。但是如果使用了 tree-shaking,则最终只有 a 会被打包。
同样的,如果是下面这种情况:
// export.js
export function a(){}
export function b(){}
// index.js
import {a,b} from ''./export.js'
a()
使用了 tree-shaking 之后,由于没有用到 b,所以最终 b 也不会被打包。
对于有副作用的代码(会向外界产生可观察的变化),tree-shaking 无法将其修剪掉。如果确定自己的项目没有副作用,可以配置 webpack.config.js 的 optimization.sideEffects: true
(生产环境自动开启),同时配置 package.json 的 sideEffects: false
,告知 webpack 无需考虑副作用的问题,可以放心进行 tree-shaking;另外也可以指定一个路径数组,明确告知 webpack 哪些文件有副作用,从而让 webpack 为其它没有副作用的文件进行 tree-shaking 处理。
前面也说过,tree-shaking 依赖于 ESModule 的静态分析,那么对于 lodash 这样不使用 ESModule 模块规范的第三方库,怎么进行 tree-shaking 呢?这时候可以考虑使用这种库的 es 版本,比如 lodash 对应的就有一个 lodash-es 版本。
webpack 在生产环境下默认开启 tree-shaking,当然也可以手动开启:
module.exports = {
optimization: {
minimizer: [
new TerserPlugin()
]
}
}
webpack 自带:Scope-Hoisting
scope-hoisting 可以将多个函数声明压缩为一个,这样做的好处一个是减少声明语句,从而减小代码体积;一个是减少函数作用域数量,从而降低内存开销。
webpack 在生产环境下默认开启 scope-hoisting,当然也可以手动开启:
module.exports = {
plugins:[
new webpack.optimize.ModuleConcatenationPlugin()
]
}
资源优化:压缩 HTML
由 html-webpack-plugin 生成的 html 文件,可以通过设置 minify: true
开启压缩功能(生产环境下默认开启)。
资源优化:压缩 JS
webpack 默认内置了 uglifyjs-webpack-plugin
或者 terser-webpack-plugin
,并且在生产环境下自动开启,因此 JS 代码默认就是经过压缩的。
当然也可以自定义配置(具体配置项需要参考 terser):
const TerserPlugin = require('terser-webpack-plugin')
module.exports = {
optimization: {
minimizer: {
new TerserPlugin({
// 只压缩 `.min.js` 后缀的文件
test: /\.min\.js$/
// 加快二次构建的速度
cache: true,
// 多线程压缩
parallel: true,
terserOptions: {
compress: {
unused: true,
drop_debugger: true,
drop_console: true,
dead_code: true
}
}
})
}
}
}
上面指的是对 JS 代码进行压缩,还有一种减少 JS 代码的策略是动态引入 polyfill。
babel 所做的事情只是转换语法,比如 const 转化为 var,箭头函数转化为普通函数等,对于诸如 map、Promise 这样比较新的 api 则无法进行处理,这时候就需要借助 polyfill 实现向下兼容。但是单纯使用 babel-polyfill 的问题在于,任何时候都是全量引入的,而有些用户的浏览器比较新,其实用不着使用 polyfill。
所以如果能实现动态引入 polyfill,也可以减少代码体积。这里借助的是 polyfill-service,我们引用它提供的 polyfill CDN,对于不同的浏览器,它会返回不同版本的 polyfill。
<srcipt src="https://polyfill.io/v3/polyfill.js"></srcipt>
资源优化:压缩 CSS
有三种方案,不管是哪一种,底层使用的压缩引擎都是 cssnano,而 css-loader 已经内置了 cssnano,因此无需额外安装。
方案一:postcss+ cssnano
需要安装 postcss-loader,接着进行配置:
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: [
'css-loader',
{
loader: 'postcss-loader',
options: {
plugins: () => [
require('cssnano')
]
}
}
]
}
]
}
}
方案二:optimize-css-assets-webpack-plugin + cssnano
适用于 webpack@5 之前的版本。需要安装 optimize-css-assets-webpack-plugin,接着进行配置:
const optimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin')
module.exports = {
plugins:[
new optimizeCssAssetsPlugin({
assetNameRegExp: /\.css$/g,
cssProcessor: require('cssnano')
})
]
}
方案三: css-minimizer-webpack-plugin
适用于 webpack@5 之后的版本。需要安装 css-minimizer-webpack-plugin,接着进行配置:
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
module.exports = {
optimization: {
minimizer: new CssMinimizerPlugin()
}
}
上面的 css 压缩都基于 cssnano,它很好用,但无法移除那些没有使用过的样式。这里可以使用 purgecss-webpack-plugin 实现 css 中的 tree-shaking。
module.exports = {
// 必须和这个插件一起使用
new MiniCSSExtractPlugin()
new PurgeCSSPlugin({
paths: glob.sync(path.join(__dirname,'./src/*/*'),{nodir: true})
})
}
注意这里的 path 并不是指要移除无用样式的 css 所在的路径,而是指引用这个 css 的文件所在的路径,可以直接配置为 src 下的所有文件。purgecss 会对这些文件进行分析,最终产出一个移除了无用样式的 css 文件。
资源优化:处理图片
从减小文件数量的角度来说:
1)可以使用前面提到的 url-loader,对体积小于 limit 的图片进行 base64 编码,转化为 dataUrl 内联进我们的应用
2)对于 svg 图片,可以使用类似的 svg-url-loader 对其进行 utf-8 编码,转化为 dataUrl 内联进应用。它相比 base64 编码的优势在于,字符串更短,浏览器解析更快
3)对于图标类图片,可以使用 postcss-loader + postcss-sprites(需要额外安装 phantomjs),它可以将多张图片合并成一张雪碧图,并且自动调整图片的背景位置。
大致配置如下:
module.exports = {
module: [
rules:[
{
test: /\.css$/,
use: [
'style-loader','css-loader',
{
loader: 'postcss-loader',
options: {
plugins: [
require('postcss-sprites')({
spritePath: './src/sprites'
})
]
}
}
]
}
]
]
}
postcss-sprites 作用的原理可以简单理解为,将 css 文件中引用到的图片资源合并成一张雪碧图,并自动处理背景图的展示位置。经由 file-loader 处理后,最后产出的 bundle 中只包含雪碧图这一张图片。
这里需要注意,spritePath
配置的是雪碧图的存放路径。一般雪碧图放在 src 中而不是 dist 中,因为 dist 中本来就会在 file-loader 的作用下产出图片,没有必要重复导出雪碧图到 dist 中 —— 即使导出了,也属于没有被使用的静态资源,会被 clean-webpack-plugin 清理掉。
此外,postcss-sprites 这个插件不支持识别 resolve.alias
配置的别名。
从减小文件大小的角度来说,对大体积的图片可以使用 image-webpack-loader 进行无损压缩。这个 loader 必须在 file-loader 之前处理图片,所以最好配置 enforce: 'pre'
。